[No QA] feat(mfa): add the magic code step and registration decision to the state machine - #355
Conversation
…machine After the device check the machine now decides whether registration is needed: a new checkLocalCredentials actor wraps a module-level areLocalCredentialsKnownToServer, so a returning user (or a flow that already carries a code) skips the magic-code screen entirely. The request-code side effect runs only on the decision transition, never on state entry, so the invalid-code retry loop cannot resend the email. validateCode and continuableError move from the legacy reducer into the machine context: an invalid code stays on the screen as a continuable error while any other rejection ends the flow through the outcome path. ValidateCodePage talks only to the internal API now. The true INVALID_VALIDATE_CODE round-trip becomes reachable in the registration slice; this slice wires and unit-tests the machine loop with mocked events.
The page clears the continuable error only when the user edits the code, so resubmitting the unchanged one carried the stale invalid-code error past the magic-code state, and the screen, which stays visible through the soft-prompt read, kept showing it while the flow had moved on. The submit transition now drops the error alongside storing the code, and a reject-then-resubmit spec pins the loop the walk cannot reach.
…tration decision validateCode is assigned only in the magic-code state, which never routes back to the decision, and every entry there goes through INIT, which resets the context. The guard could therefore never pass in a running flow; the spec exercising it only passed by seeding the code into the context by hand. The slice that loops registration back into the decision can reintroduce the guard together with the path that makes it reachable.
The submitted code now drives a real backend round-trip: a new requestingRegistrationChallenge state invokes an actor that wraps requestRegistrationChallenge, and the machine routes on the normalized result. A valid challenge lands in the machine context and the flow continues; an invalid code returns to the magic-code screen as the inline, continuable error; anything else ends the flow through the outcome path. This retires the mocked VALIDATE_CODE_REJECTED event and its walk exclusions, so the graph walk now drives the invalid-code retry loop through the real UI, including a dedicated journey. The legacy reducer's registrationChallenge field moves into the machine context with no remaining legacy consumers. The response's publicKeys stay unused until the registration slice reconciles local credentials.
…ured at flow start The registration decision needed the same credentials check the Provider already runs for start telemetry, and the machine actor duplicated the hook logic in the operations modules to get it, costing a second native keystore read per flow start. INIT now carries the captured localCredentialsKnownToServer flag, the decision becomes an eventless transition on it, and the operations copies and their suites go away, leaving the hooks as the single implementation. A keystore read failure now routes to registration instead of a fatal outcome, because the hooks resolve the check to false instead of rejecting; re-registration recovers such an account anyway. The UI walk grows stronger: the INIT executor seeds the biometrics hook mock, so the flag flows through the real Provider wiring, and both decision branches traverse via INIT fixture variants.
…hot captured at flow start" This reverts commit 550345f.
The resend button called requestValidateCodeAction directly from the view, bypassing the machine that owns every other send of the magic-code email. A new RESEND_VALIDATE_CODE event, accepted only while the magic-code screen waits for a code, makes the machine the single sender: a resend fired while the registration challenge request is in flight is dropped, and a resend also clears the stale inline invalid-code error. The view keeps only its UI-local cleanup and now disables the resend button on the request that actually loads during a resend (the `??` in the disable condition never reached its right-hand side).
… flight The machine drops a resend sent during the challenge request, but the button stayed pressable once the countdown expired, so a press cleared the input and restarted the countdown without a new email coming. The view now reads a flag derived from the machine snapshot, which cannot lag behind the state that decides whether the event is accepted, unlike the account loading state delivered through Onyx.
Upstream 6e87a8c started passing REGISTER_AUTHENTICATION_KEY on both sends of the magic-code email, and this slice moved both of them behind the machine's requestValidateCode action, so the action has to carry the reason code or the backend loses the context it was just given.
Upstream 2f096a5 renamed the user-facing magic-code keys to security-code. The page picked the new key up through the sync, the walk assertion still read the removed one and threw on every path that renders the inline error.
The Onyx bump that came with the sync widened the connect callback to a collection-aware conditional type, which no longer matches OnyxEntry. OnyxValue is the type Onyx resolves the callback to, and it is what tests/utils/getOnyxValue already uses.
7f77611 to
9e20ab4
Compare
Replace the continuableError context field and the CLEAR_CONTINUABLE_ERROR command event with an invalidCode substate of awaitingValidateCode. Every way out of the substate (typing, a resend, a new submission) clears the inline error by construction, so the three manual clear sites disappear. The VALIDATE_CODE_CHANGED event states what happened instead of commanding a context write, and the view reads the showsInvalidCodeError tag through snapshotToState. The stored MFAError payload had no consumers, so nothing replaces it.
Replace the hand-rolled done and error event shapes with XState's DoneActorEvent and ErrorActorEvent, keyed by actor id instead of by event type. The derived union lives in machine/machineEvents.ts so the machine module stays focused on the chart. Graph-traversal fixtures now hold one entry per actor, built by createActorEvents. Its non-empty return type carries the "at least one output variant" guarantee into the fixture table, and the keyed type pins each slot to that actor's own events. getTraversalEvents filters a single fixture list and keeps a separate branch for framework events that cannot be given a fixture at all. Also fixes flowActors.ts, which still called the previous createActorDoneEvent signature and did not compile.
| async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise<boolean> { | ||
| const localCredentialID = await getLocalCredentialID(accountID); | ||
| if (!localCredentialID) { | ||
| return false; | ||
| } | ||
| const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal); | ||
| return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); |
There was a problem hiding this comment.
NAB (native and web): we don't distinguish credentials that were not loaded yet from those non-existing (both undefined) - is there a chance, perhaps is there any chance that this actor runs before MFA data arrives from OpenApp? I think it's a small possibility for that, but if so, it would trigger registration flow unnecessarily
what's more, we could have false positive too (server credentials are gone but local credentials still exist), but only if multifactorAuthenticationPublicKeyIDs is not hydrated yet. it's worth to take under consideration in recovery slice
| const requestRegistrationChallengeActor = fromPromise<RequestRegistrationChallengeOutput, RequestRegistrationChallengeInput>(async ({input}) => { | ||
| const {challenge, httpStatusCode, reason, message} = await requestRegistrationChallenge(input.validateCode); | ||
| if (!isHttpSuccess(httpStatusCode) || !challenge) { | ||
| return {success: false, error: createMFAErrorFromApiResponse(httpStatusCode, reason, message)}; | ||
| } | ||
| return {success: true, challenge}; | ||
| }); | ||
|
|
There was a problem hiding this comment.
this actor doesn't accept an AbortSignal like other functions in this file, so it can resolve after the modal closes or the actor stops. Its finallyData unconditionally clears isLoading on ONYXKEYS.ACCOUNT, dismissing the modal and starting a new flow before the stale request finishes can let it clear the new flow's loading state causing race condition. I think it should skip the onyx write when aborted
| // Accepted only here: an INIT sent while the modal is open or still closing is | ||
| // dropped rather than started on dirty state. | ||
| INIT: {target: MFA_STATE.OPEN, actions: 'initFlow'}, |
There was a problem hiding this comment.
i think it can cause race condition - if INIT is sent during closing (e.g. we are waiting for closeFallback) as the comment says, it's dropped without any notice (if (state.modalState !== MFA_STATE.CLOSED) return; in MultifactorAuthenticationMainContext.tsx L60)), but executeScenario could fire side effects
| types: { | ||
| context: {} as MfaContext, | ||
| events: {} as MfaEvent, | ||
| events: {} as MfaMachineEvent, |
There was a problem hiding this comment.
from my understanding, we extended the machine's event type to include XState's internal actor-lifecycle events, but only to satisfy the graph-traversal tests. The production send() inherits that same widened type, so nothing stops real app code from constructing e.g send({type: 'xstate.done.actor.requestRegistrationChallenge', output: {success: true, challenge: fakeChallenge}}); directly and skipping the real backend check. Could we narrow the production back to MfaEvent and keep the wider typing test-only?
| function readOnyxValueOnce<TKey extends OnyxKey>(key: TKey, signal?: AbortSignal): Promise<OnyxValue<TKey>> { | ||
| return new Promise((resolve) => { | ||
| if (signal?.aborted) { | ||
| return; | ||
| } | ||
|
|
||
| let connection: Connection; | ||
| const disconnect = () => Onyx.disconnect(connection); | ||
|
|
||
| signal?.addEventListener('abort', disconnect, {once: true}); | ||
| connection = Onyx.connectWithoutView({ | ||
| key, | ||
| callback: (value) => { | ||
| signal?.removeEventListener('abort', disconnect); | ||
| disconnect(); | ||
| resolve(value); | ||
| }, | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
NAB: maybe we should safely reject a promise after aborting instead of never resolving, as someone could misuse this helper outside of MFA (even though they shouldn't have)
| const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); | ||
| const shouldDisableResendCode = isOffline ?? account?.isLoading; | ||
| // The MFA registration challenge always uses VALIDATE_CODE_FORM, even when the account has 2FA enabled. | ||
| const isValidateCodeFormSubmitting = !!account?.isLoading && account.loadingForm === CONST.FORMS.VALIDATE_CODE_FORM; |
There was a problem hiding this comment.
i think this should depend on machine state/snapshot, not onyx as the flag can be stale/cleared
| onDone: [ | ||
| { | ||
| guard: ({event}) => event.output.success, | ||
| target: SOFT_PROMPT_CHECK_TARGET, | ||
| actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}), | ||
| }, |
There was a problem hiding this comment.
validateCode is only read once right there, further steps don't need it, but it's cleared only when entered CLOSED state. I think we should clear it before leaving this state, because it's sitting in context for the rest of the flow.
| expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); | ||
| // A stored code means the flow re-entered this check from the magic-code screen, which | ||
| // stays visible while the read runs; a first pass runs behind the transparent initial screen. | ||
| if (state.context.validateCode === undefined) { |
There was a problem hiding this comment.
take under consideration this test, if we clear validateCode as suggested here https://github.com/software-mansion-labs/expensify-app-fork/pull/355/changes#r3690593440
because we use validateCode as a flag/indicator here
Gate areLocalCredentialsKnownToServer on HAS_LOADED_APP and IS_LOADING_APP so the check does not read ACCOUNT before OpenApp data arrives and start registration unnecessarily. Generalize readOnyxValueOnce into a predicate-based waitForOnyxValue and rename the module accordingly. On web, return early when there are no local passkeys so the gate never delays an already determined answer. The gate guarantees hydrated data, not fresh data. Reconciling credentials revoked while the app was closed stays with the recovery flow.
PR Stack
Part of the MFA flow to XState migration (#81197). The migration lands as a stack of vertical slices on the integration branch
dariusz-81197-mfa-state-machine, and nothing reachesmainuntil the final integration merge. The stack lives on thesoftware-mansion-labs/expensify-app-forkremote, so this PR is opened there; its parent slice (#352) is already merged, so this PR targets the integration branch directly.dariusz-biela/refactor/3ds/mfa-test-reachability-and-ui-walkdariusz-81197-mfa-state-machinedariusz-biela/feat/3ds/mfa-device-check-and-failure-screendariusz-81197-mfa-state-machinedariusz-biela/feat/3ds/mfa-soft-promptdariusz-81197-mfa-state-machinedariusz-biela/feat/3ds/mfa-magic-code-and-registration-decisiondariusz-81197-mfa-state-machineExplanation of Change
Migrates the magic-code step and the registration decision from the legacy reducer into the MFA state machine, as the next vertical slice of the XState migration. The submitted code is exchanged with the backend for a registration challenge, so the whole loop (submit, reject, resend, retry) runs against the real round-trip.
In the app
In the code
decidingRegistrationstate invokes a newcheckLocalCredentialsactor overareLocalCredentialsKnownToServer(accountID), added to the platform-resolvedbiometrics/operationsmodules (native reads the HSM key, web the stored passkeys, both compared against the server-known credential IDs). Anything short of a local key the server also knows means registration, so a missing key or a failed keystore read simply re-registers.magicCodeparent state owns the screen and navigates to it on entry. ItsawaitingValidateCodechild waits for the code and is the only place a resend is accepted, so one fired mid-request is dropped;snapshotToStateexposes that ascanResendValidateCode, which drives the button instead of Onyx loading flags. The siblingrequestingRegistrationChallengechild runs the exchange: an invalid code returns toawaitingValidateCodeas an inline error, anything else ends the flow. The email request is an action on the decision transition, so it fires exactly once.validateCode,continuableError, andregistrationChallengemove from the legacy reducer into the machine context, andValidateCodePagetalks only to the machine-backed internal API.loadingForm === VALIDATE_CODE_FORMinstead ofAccountUtils.isValidateCodeFormSubmitting, which switches to the 2FA form key and so never matched here.areLocalCredentialsKnownToServeron both platforms.Out of scope
readOnyxValueOnce, a one-shot Onyx read helper extracted from the soft-prompt actor and reused by the operations modules.areLocalCredentialsKnownToServernow exists twice: the React-free operation the machine uses, and the biometrics hooks, which still back the Provider's start telemetry. The hook copies retire when those callers migrate.publicKeysstay unused until the registration slice, which also brings the key creation andRegisterAuthenticationKey.Fixed Issues
$ Expensify#81197
PROPOSAL: N/A (internal engineering migration, part of the MFA XState refactor)
Tests
Run the app in dev on this branch, on an account whose device credentials are not registered with the server (a fresh dev account works).
main, complete the MFA registration there on this account and device, then come back to this branch and run the scenario again. Verify the magic-code screen is skipped and no email arrives.Offline tests
QA Steps
[No QA] - internal stacked PR on the MFA XState integration branch. The flow is intentionally partial between slices and does not reach staging until the final integration merge.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari